fix(cli): os init and os generate emit Data.ServiceObject so scaffolded projects type-check - #16193
Conversation
`os init -t app`, `os init -t plugin` and `os g object` all wrote `const … : Data.Object`, and `@objectstack/spec/data` exports no member named `Object`. The primary scaffolder therefore emitted a project that failed its own `pnpm typecheck` with TS2694 (tsc exit 2), measured against the published tarball a real user installs and identical at TypeScript 5.3.3, 5.8.3 and 6.0.3. `Data.ServiceObject` is `z.input<typeof ObjectSchemaBase>` — the authoring shape of an object, and the structural analogue of the annotations the sibling generators already emit (`UI.View`, `UI.Action`, `UI.Dashboard`, `Automation.Flow`). It has always been exported; nothing was added to `@objectstack/spec`. The hand-written docs already used it, so this moves the scaffolders onto the repo's own spelling. `content/docs/deployment/cli.mdx` documented the broken annotation and is corrected with the emitters. Adds a type-layer pin over both emitter rosters. The two existing scaffold sweeps load the emission through esbuild, which erases type annotations without checking them, so a broken annotation is invisible to them by construction; the new pin spawns a real `tsc` over the materialized project under the tsconfig the scaffolder itself renders, with a canary that must fail TS2694 so the harness cannot pass by resolving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…eck pin `check:cli-test-child-env` (#11595) requires every spawn under `packages/cli/test/**` to declare its child's `env`, so that what the child inherits is legible at the call site instead of being the vitest worker's environment by default. The new pin's `spawnSync` passed an options object with no `env` key and the gate named it. Routed through `childEnv()`, this directory's choke point, with `NO_COLOR` to match the `--pretty false` already passed to tsc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f9387818deb06aa8efa45f4203e6a2e383bc6003 && git checkout f9387818deb06aa8efa45f4203e6a2e383bc6003
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4c0b22bf758a7a51e46bb756310d3ada29197f0c 4cf3f44ab86b07e2fe199e091f8642efa279efc9 && git checkout -B drift-repro 4c0b22bf758a7a51e46bb756310d3ada29197f0c && git merge --no-ff 4cf3f44ab86b07e2fe199e091f8642efa279efc9
node scripts/docs-audit/affected-docs.mjs --json 4c0b22bf758a7a51e46bb756310d3ada29197f0c
|
Contract review (clause ②) — CHANGES REQUESTED, one line: the changeset levelReviewed by an isolated subagent at the contract-review tier ( 转录档位核验 — PASSED. Per the reference's The ruling on the
|
…inor` AGENTS.md: "A bug fix in a released package takes a `patch` changeset -- never none, and never `skip-changeset`". This is that shape -- a scaffolder emitted broken text and now emits working text -- matching two `@objectstack/cli` 17.3.0 Patch-section precedents, 5dee191 (#13871, `os generate` ghost field types) and 9786d39 (`os create example` manifest identity block). The `minor` grade rested on a precedent of the wrong class (an additive key on a machine-readable --json payload, i.e. a contract widening), and on the claim that "patch would assert no surface moved" -- which is not what patch asserts, since every fix moves output bytes. The body now rests the annotation choice on ADR-0122 D1, which already ruled that the bare alias denotes the author state and is the name authoring surfaces use, rather than on two docs-page citations that do not support it: `metadata-driven.mdx`'s `const Account: ServiceObject` line is that page's "Deprecated" example, and `quick-reference.mdx` only names the type in an import list. Adds the measured result for the parsed-state alias: TS2740 on all three emissions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Landing — provenance ·
|
Fixes #15976
What was broken
os init -t app,os init -t pluginandos g objecteach emitted an object file annotatedconst … : Data.Object.@objectstack/spec/dataexports no member namedObject, so the primary scaffolder — the first command a new user runs — produced a project that failed its ownpnpm typecheck.Re-driven on this branch through the scaffolders' own emitters (
TEMPLATES+writeTemplateSrcFiles,GENERATOR_SCAFFOLD_TARGETS) into a sandbox carrying the tsconfigrenderScaffoldTsconfigreally writes, then compiled withtsc --noEmit— the exact command the emittedpackage.jsonbinds totypecheck:mainos init -t appos init -t pluginos g objectThe repair, and why it needed nothing from
packages/specThe annotation is now
Data.ServiceObject. That name was not chosen here. ADR-0122 D1 (Accepted 2026-08-06; phase 2 landed in@objectstack/spec17.0.0) already ruled it:An emitted scaffold is the thing an author writes, so the bare alias is the one it owes. The sibling generators were already on that convention —
UI.View,UI.Action,UI.DashboardandAutomation.Floware each the bare alias of their own schema — and only the object emitters had drifted off it.The parsed-state alias is not a live alternative, and this is measured rather than argued. Annotating the same three emitted literals
Data.ServiceObjectParsedinstead:Both arms driven, so the reading can fail in either direction. That is precisely the author-state / parsed-state split ADR-0122 D2 draws: the emitted literal omits every defaulted key, which is legal input and illegal output.
No export was added to
@objectstack/spec;ServiceObjecthas been exported from@objectstack/spec/datathroughout.⛔ Retraction — two citations withdrawn, not quietly edited
An earlier revision of this body rested part of the case on "the hand-written docs already annotate authored objects with
ServiceObject", citingcontent/docs/concepts/metadata-driven.mdxandcontent/docs/getting-started/quick-reference.mdx. Contract review falsified both, and I confirmed the falsification against the tree:metadata-driven.mdx:363— theconst Account: ServiceObject = {line sits under that page's "❌ Deprecated:" heading, commented// Old pattern - no runtime validation, beneath the rule "### 1. Always UseObjectSchema.create()withField.*Helpers". It is the pattern the page tells authors not to write. Citing it as precedent was wrong.quick-reference.mdx:295— namesServiceObjectonly in animport type { … }example. It annotates no authored object.grep -nE ": *ServiceObject\b"over that page returns nothing.The conclusion is unchanged; that support is withdrawn. The claim now rests on ADR-0122 D1 and on the sibling generators, both of which were verified directly.
metadata-driven.mdxcalls the plain annotated literal the deprecated shape andObjectSchema.create()the correct one — whileos initandos g objectemit the plain literal. Whether the scaffolder should emit the factory shape is a separate question, out of scope here, and it is the same underlying mismatch already recorded in #16195 (all 112 real*.object.tsfiles in the tree use the factory form).content/docs/deployment/cli.mdxtaught the broken spelling too, and is corrected with the emitters; a reader copying from that line wrote the same uncompilable file.The two members the card left un-swept
The docs line also named
UI.ViewandAutomation.Flow, which the card explicitly did not sweep. Both are genuinely exported, and this was verified by driving them rather than by grep: every generator on the roster was emitted and compiled.Data.Objectwas the only non-existent member any emitter named.os g view,os g action,os g flow,os g dashboard,os g appandos g skillall compiled at exit 0 onmain, before any change here.Why nothing caught it
Both existing scaffold sweeps could not see this defect — not by omission, but by construction:
generate-scaffold-validates.test.tsloads each scaffold throughbundle-requireinit-scaffold-authoring-rules.test.tsloads each template through the command's ownvalidateScaffoldBoth are runtime pins: they materialize the TypeScript and execute it. The loader underneath is esbuild, which erases type annotations without checking them, so
Data.ObjectandData.ServiceObjecttranspile to byte-identical JavaScript. The scaffolds genuinely parsed, validated and loaded — they simply never compiled, and nothing here had ever asked a compiler.The new pin, and the direction it fails in
packages/cli/test/scaffold-emission-typechecks.test.tsadds the missing axis. It derives its rosters fromTEMPLATESandGENERATOR_SCAFFOLD_TARGETS, renders the tsconfig fromrenderScaffoldTsconfigrather than restating it, and spawns a realtsc.Reverse verification. With
main's emitter text restored over the fix (the twoinit.tssites and thegenerate.tssite, mutation confirmed on disk by counting both the removed and the injected text before running), the pin goes red on exactly three cases and no others:The tree was then restored to
HEADand the restore proved byte-level —git diff HEADempty, and both files'git hash-objectequal to theirHEADblob hashes.A reading that could not fail was the thing to avoid here. A tsc harness that resolves nothing, or discovers no files, reports zero errors and reads exactly like a pass. So the pin carries a canary that compiles a deliberately absent member of the same namespace under the same profile and is asserted to fail with TS2694 — the incident's own error code. A second control pins that an object file importing
@objectstack/spec/datais still emitted at all, so the pin cannot quietly stop covering this incident by the emission being removed.Three docblock precision notes recorded by review — accurate, not folded
Each was re-checked against the tree and each is correct. None is a defect in the pin's logic, and this round may not move a test file, so they are recorded here rather than edited in:
includeprofile differs between the two case families —os initsandboxes useSCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIGwithrootDir: '.'(the emitted project compiles its rootobjectstack.config.tstoo), whileos gsandboxes useSCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLYwithrootDir: 'src'. The docblock says the options come fromrenderScaffoldTsconfigwithout noting that it is called with two different profiles.init.ts:4is a value import (import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'), so with no dist the suite dies at module collection, before the canary runs. The advice is right about the remedy and wrong about the symptom that would carry a reader to it.@objectstack/specthroughpackages/cli/node_modulesto the workspacepackages/spec/dist. That is the closest available approximation without a network install, not the published artifact, and the docblock's "exactly as it does for a real user's project" overstates it.The dependency this PR does not discharge
Out of scope: #15531.
os initstill has no end-to-end CI gate —scripts/create-scaffold-smoke.shcoverscreate.tstemplates only — and that gap is what hid this defect. The audit for it has landed, but until an end-to-end gate exists, the green here is one-shot: this PR pins the type axis of the emitters, not the install-and-build path a real user walks.Changeset level —
patchpatchon@objectstack/cli. The governing rule is AGENTS.md:This is exactly that shape: a scaffolder emitted broken text and now emits working text. Two lane precedents for the same defect shape sit in
@objectstack/cli17.3.0's Patch section —5dee191(#13871,os generateghost field types) and9786d39(os create examplemanifest identity block).⛔ Correcting this body's own earlier reasoning. It previously graded
minorand argued "patch would assert no surface moved". That is not whatpatchasserts — every bug fix moves output bytes, and if moved bytes impliedminorthe patch grade would have no population at all. Theminorgrade also leaned on a precedent of the wrong class (an additive key on a machine-readable--jsonpayload, i.e. a contract widening, not a fix). Nothing about the emitted output changed between the two gradings; only the grade did.Clause ② grading, per limb
Mechanical floor —
no. Nothing in the diff sits underpackages/spec/src/**, and no key is added to any published payload. Files touched: two CLI command sources, one docs page, one changeset, one new test.Conformance limb — graded
yes, and here is what has changed under it. It was gradedyeson the ground that the call was not clear and the rule is to gradeyeswhen it is not. Review then identified the governing ruling: ADR-0122 D1, which already decided that the bare alias is the author state and is the name authoring surfaces use. On that basis this is not a re-selection between two published verdicts — the selection was ruled in 2026-08-06 and this PR conforms to it, which readsno.⛔ I am not unilaterally downgrading the limb:
needs:contract-reviewis already hung on this PR and on the card, and the disposition belongs to the reviewing seat. Both readings are stated so the downgrade is a decision on the record rather than a silent edit. ⛔ The diff was not shaped to keep any limbno.Governed surfaces
None touched. The diff contains no
docs/adr/**,.claude/**,skills/**,AGENTS.mdorCLAUDE.mdpath, and nothing undercontent/docs/releases/. The PR is left as a draft, not enqueued, with no auto-merge armed, and no label was touched.Verification
Re-run whole at head
4cf3f44ab86, not carried over from the previous head.Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, asserted against that tool's ownReconciliation — 87 famil(ies)line: 87 / 87 exit 0. The derived family set is byte-identical to the previous head's, as expected — the change set is the same five paths.Changeset-specific families, named because they are the ones this round moves:
check-changeset-no-major,check-empty-changeset,check-changeset-fixed,check:changeset-gate-self-tests,check:objectui-changeset— all exit 0.The Artifact rosters block was run separately, as it sits outside that total: 34 / 37 exit 0. The same three are NOT MEASURED, named with exit codes rather than folded into a green count:
check-partof-closing-keyword.mjs— exit 2, NOT WIRED (needsPR_BODY/PR_NUMBER). Run locally against this body withPR_BODYset: exit 0.check-single-claim-paths.mjs— exit 2, NOT WIRED (needsPR_NUMBER+ a repo token; repo-scoped REST is 403 from this container). CI'sNo other open PR may claim the same single-writer pathcheck is the authority, not any local run.check:react-declaration-parity— exit 1, MANIFEST not set: the registry side is objectui'ssdui.manifest.json, needing a sibling checkout and a browser dump. Unrelated to this diff.Tests carried from head
c2587f6aa4c, whose source tree is byte-identical to this head — this round changed one.changeset/*.mdfile and nothing else, so no test input moved:packages/cli/test/scaffold-emission-typechecks.test.ts— 12 passed@objectstack/clitest that importscommands/initorcommands/generate(13 files), plus two adjacent scaffold suites: 15 files, 244 passed + 6 expected-fail (250)pnpm --filter @objectstack/cli typecheck— exit 0, both halves (tsc --noEmitandcheck:test-typecheck). The new test file was confirmed present in the test-layer program via--listFiles, against a sibling-file control.@objectstack/clisuite (267 files, 56 of them e2e) was not run locally — it is a CI-shaped run. The narrowing is derived rather than chosen: the population is every test importing the two modules this PR edits. A green local union is not a prediction of green CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N